iT邦幫忙

2024 iThome 鐵人賽

DAY 23
0

在今天的挑戰中,我們要加入一個重要的功能,就是掃描發票 QRCode 來幫助使用者加入家用品。這個功能會幫助使用者能夠更快速、方便的加入所購買的家用品。雖然今天的目標只集中在掃描和取得內容,但這將為接下來的工作打下基礎。

目標

  • 在 AddItemView 中新增一個相機按鈕,點擊後開啟相機。
  • 實作掃描 QRCode 功能並取得內容。
  • 掃描前可開啟取得權限的畫面。

主要實作

在 AddItemView 中新增掃描按鈕

在新增頁面的右上角新增一個相機按鈕,讓使用者可以點擊開啟相機進行 QRCode 掃描。

struct AddItemView: View {
    @ObservedObject var viewModel: AddItemViewModel
    @State private var scanResult: String = "No QR code detected"  // 儲存掃描結果
    
    var body: some View {
        VStack {
            //...中間略
            .navigationBarItems(trailing: NavigationLink(destination: QRScannerView(result: $scanResult)) {
                Image(systemName: "camera")
                    .font(.title2)
            })
        }
    }
}

這段程式碼新增了一個 NavigationLink,它會打開我們稍後實作的 QRScannerView。

https://ooorito.com/wp-content/uploads/2024/09/%E6%88%90%E6%9E%9C-472x1024.webp

實作 QRScannerView

接著我們需要建立一個 QRScannerView,使用 UIViewControllerRepresentable 來將 UIViewController 包裝成 SwiftUI 可以使用的 View。

UIViewControllerRepresentable

UIViewControllerRepresentable 是 SwiftUI 中的一個協定,用來將 UIKit 的 UIViewController 轉換成 SwiftUI 的 View,使我們可以在 SwiftUI 中使用現有的或自定義的 UIViewController。它允許我們在 SwiftUI 的架構中引入 UIKit 的功能,像是使用相機、地圖、或是其他 UIKit 的 Controller。

參考資料:

struct QRScannerView: UIViewControllerRepresentable {
    @Binding var result: String  // 掃描結果
    
    func makeUIViewController(context: Context) -> QRScannerController {
        let scannerController = QRScannerController()
        scannerController.delegate = context.coordinator  // 設置 delegate 處理掃描結果
        return scannerController
    }
    
    func updateUIViewController(_ uiViewController: QRScannerController, context: Context) {
        // 無需更新
    }
    
    func makeCoordinator() -> Coordinator {
        Coordinator($result)
    }
}

這裡使用 UIViewControllerRepresentable 來將 QRScannerController 引入到 SwiftUI 畫面中,並透過 Coordinator 來處理掃描結果。

建立 Coordinator

Coordinator 負責接收來自相機掃描的結果,並將結果傳回 SwiftUI。

在 SwiftUI 中,Coordinator 的作用是充當橋樑,讓 UIKit 的控制器(例如相機)與 SwiftUI 進行溝通。由於 SwiftUI 和 UIKit 是兩個不同的框架,處理交互事件時,我們需要一個協調者來處理 SwiftUI 和 UIKit 之間的資料傳遞,這就是 Coordinator 的用途。

class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate {
    @Binding var scanResult: String

    init(_ scanResult: Binding<String>) {
        self._scanResult = scanResult
    }

    func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
        if let metadataObject = metadataObjects.first as? AVMetadataMachineReadableCodeObject, metadataObject.type == .qr {
            if let stringValue = metadataObject.stringValue {
                scanResult = stringValue  // 更新掃描結果
            }
        }
    }
}

這段程式負責當掃描到 QRCode 後,將內容更新 scanResult。

設定相機權限 (Info.plist)

在 iOS App 中,使用相機等個人資訊時,必須向使用者請求相對應的權限。在這次我們的目標中,掃描 QRCode 需要用到相機,因此我們必須在 Info.plist 中新增相機使用權限的說明。

步驟:

  1. 打開 Info.plist 文件:在 Xcode 左邊的列表中,找到並選擇 Info.plist 文件。
  2. 新增相機使用描述:
    • 在 Info.plist 中,新增一個新的 key NSCameraUsageDescription。
    • 對應的值必須提供需要使用者的相機使用原因說明。例如:"需要使用相機來掃描 QRCode"。

Info.plist 範例:

<key>NSCameraUsageDescription</key>
<string>需要使用相機來掃描 QRCode</string>

NSCameraUsageDescription:這是 Apple 要求的關鍵,用來解釋為什麼應用需要使用相機。在 App 運行時,當我們第一次嘗試開啟相機時,iOS 會彈出一個對話框,顯示這個描述,讓使用者了解這一權限的用途。

實作 QRScannerController

QRScannerController 是一個 UIViewController,負責顯示相機並進行 QRCode 掃描。當掃描到 QRCode 後,它會將結果傳回 SwiftUI。

建立 QRScannerController 類別

首先需要建立一個自定義的 UIViewController 來管理相機的掃描功能。

class QRScannerController: UIViewController, AVCaptureMetadataOutputObjectsDelegate {
    
    var captureSession = AVCaptureSession()
    var videoPreviewLayer: AVCaptureVideoPreviewLayer?
    var qrCodeFrameView: UIView?
    var onQRCodeScanned: ((String) -> Void)?
    
    override func viewDidLoad() {
        super.viewDidLoad()
        checkCameraAuthorization()
    }
}
  • AVCaptureSession:用於捕捉視訊及音訊,協調視訊及音訊的輸入及輸出。
  • AVCaptureVideoPreviewLayer:用來在螢幕上顯示相機畫面的圖層。這個圖層將相機的輸入影像實時顯示出來,讓用戶可以看到他們正在掃描什麼。
  • AVCaptureMetadataOutputObjectsDelegate:用來捕捉並輸出資料的方法。

參考資料:QRCode掃起來!

檢查相機權限

我們要先取得相機的使用權限,因此需要加入檢查和請求相機使用權限的邏輯。

func checkCameraAuthorization() {
    switch AVCaptureDevice.authorizationStatus(for: .video) {
    case .authorized:
        // 已授權,開始配置相機
        setupCamera()
    case .notDetermined:
        // 尚未決定,請求授權
        AVCaptureDevice.requestAccess(for: .video) { granted in
            DispatchQueue.main.async {
                if granted {
                    self.setupCamera()
                } else {
                    self.showPermissionAlert() // 顯示權限不足提示
                }
            }
        }
    case .denied, .restricted:
        // 已被拒絕或限制,顯示提示
        showPermissionAlert()
    @unknown default:
        fatalError("Unexpected case for camera permission.")
    }
}
  • 檢查權限:AVCaptureDevice.authorizationStatus(for: .video) 檢查 App 對相機的權限狀態。
  • 請求權限:如果權限未決定 (notDetermined),請求相機權限並在使用者決定後進行相對應操作。
  • 顯示提示:當權限被拒絕時,我們會顯示一個對話框來告知用戶需要打開權限。

設定相機

已經取得相機權限後,可以開始設定相機的輸入和輸出,並將相機畫面顯示在螢幕上。

func setupCamera() {
    captureSession = AVCaptureSession()

    guard let captureDevice = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) else {
        print("無法取得相機裝置")
        return
    }

    do {
        let input = try AVCaptureDeviceInput(device: captureDevice)
        captureSession.addInput(input)

        let captureMetadataOutput = AVCaptureMetadataOutput()
        captureSession.addOutput(captureMetadataOutput)

        // 設置 delegate 以監聽 QRCode 掃描結果
        captureMetadataOutput.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
        captureMetadataOutput.metadataObjectTypes = [.qr]

        videoPreviewLayer = AVCaptureVideoPreviewLayer(session: captureSession)
        videoPreviewLayer?.videoGravity = .resizeAspectFill
        videoPreviewLayer?.frame = view.layer.bounds
        view.layer.addSublayer(videoPreviewLayer!)

        DispatchQueue.global(qos: .background).async {
            self.captureSession.startRunning()
        }
        
    } catch {
        print("Error occurred while setting up camera: \(error)")
    }
}
  • AVCaptureDeviceInput:這將相機作為輸入設備加入到 captureSession 中。
  • AVCaptureMetadataOutput:我們使用這個來監聽 QRCode 掃描結果,並將 QRCode 類型加入到 metadataObjectTypes 中。
  • AVCaptureVideoPreviewLayer:這一部分負責將相機的畫面顯示在 view 上。

處理 QRCode 掃描結果

當掃描到 QRCode 時,會觸發回調方法,我們可以在這裡處理掃描結果,並且提供使用者震動反饋。

func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
    if let metadataObject = metadataObjects.first {
        guard let readableObject = metadataObject as? AVMetadataMachineReadableCodeObject,
              let stringValue = readableObject.stringValue else { return }
        AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate))
        qrCodeFrameView?.frame = videoPreviewLayer?.transformedMetadataObject(for: metadataObject)?.bounds ?? .zero
        
        onQRCodeScanned?(stringValue)
    }
}
  • 震動反饋:當成功掃描到 QRCode 時,我們通過 AudioServicesPlaySystemSound 讓設備震動,告訴用戶掃描成功。

顯示權限不足的提示

如果使用者拒絕相機權限,我們需要提醒他去設定中打開權限。

func showPermissionAlert() {
    let alert = UIAlertController(title: "相機權限不足", message: "請到設置中開啟相機權限", preferredStyle: .alert)
    alert.addAction(UIAlertAction(title: "確定", style: .default))
    present(alert, animated: true)
}

有點長,這邊提供完整程式碼:

class QRScannerController: UIViewController, AVCaptureMetadataOutputObjectsDelegate {
    var captureSession = AVCaptureSession()
    var videoPreviewLayer: AVCaptureVideoPreviewLayer?
    var qrCodeFrameView: UIView?
    var onQRCodeScanned: ((String) -> Void)?
    var delegate: AVCaptureMetadataOutputObjectsDelegate?

    override func viewDidLoad() {
        super.viewDidLoad()
        checkCameraAuthorization()
    }
    
    // 掃描 QRCode 後觸發
    func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
        if let metadataObject = metadataObjects.first as? AVMetadataMachineReadableCodeObject, let stringValue = metadataObject.stringValue {
            print("QR Code: \(stringValue)")  // Debug 用
        }
    }

    func checkCameraAuthorization() {
        switch AVCaptureDevice.authorizationStatus(for: .video) {
        case .authorized:
            setupCamera()  // 已授權,開始配置相機
        case .notDetermined:
            AVCaptureDevice.requestAccess(for: .video) { granted in
                if granted {
                    DispatchQueue.main.async {
                        self.setupCamera()
                    }
                }
            }
        default:
            print("未授權使用相機")
        }
    }

    func setupCamera() {
        guard let captureDevice = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) else {
            print("無法取得相機裝置")
            return
        }
        
        do {
            let input = try AVCaptureDeviceInput(device: captureDevice)
            captureSession.addInput(input)

            let captureMetadataOutput = AVCaptureMetadataOutput()
            captureSession.addOutput(captureMetadataOutput)
            captureMetadataOutput.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
            captureMetadataOutput.metadataObjectTypes = [.qr]

            videoPreviewLayer = AVCaptureVideoPreviewLayer(session: captureSession)
            videoPreviewLayer?.videoGravity = .resizeAspectFill
            videoPreviewLayer?.frame = view.layer.bounds
            view.layer.addSublayer(videoPreviewLayer!)

            captureSession.startRunning()
        } catch {
            print("相機初始化失敗: \(error)")
        }
    }
}

QRScannerController 是一個相機的 Controller,使用 AVCaptureSession 來配置相機,並處理 QRCode 掃描結果。

總結

今天練習到如何在 SwiftUI 使用 UIKit,沒有想到這兩樣東西要連結起來這麼麻煩的事情!這次實作 QRCode 掃描功能,包括在 AddItemView 中新增一個相機按鈕,開啟 QRCode 掃描畫面,並取得 QRCode 中的資料。明天我們再接續解析資料,製作 QRCode 掃描加入家用品功能吧!


上一篇
Day 22: 更新帳務報表頁面 - 顯示分類比例與總金額
下一篇
Day 24: 掃描 QRCode 並顯示列表
系列文
用 SwiftUI 掌控家庭日用品庫存30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言